Skip to content

feat: verify ML-DSA server certificates (requires Go 1.27) - #414

Open
nihiloid wants to merge 12 commits into
refraction-networking:masterfrom
nihiloid:go127-mldsa-support
Open

feat: verify ML-DSA server certificates (requires Go 1.27)#414
nihiloid wants to merge 12 commits into
refraction-networking:masterfrom
nihiloid:go127-mldsa-support

Conversation

@nihiloid

Copy link
Copy Markdown
Contributor

Why this needs Go 1.27, and why that breaks consumers

This pull request raises the minimum Go version from 1.24 to 1.27. A consumer on Go
1.24, 1.25, or 1.26 cannot build the library after this change.
The reasons come
first, because this cost must be clear before the feature.

  1. ML-DSA signature verification needs crypto/mldsa. That package starts with Go
    1.27. No earlier release holds it.
  2. Certificate chain verification needs ML-DSA in crypto/x509. Go 1.27 adds the
    MLDSA public key algorithm, the MLDSA44, MLDSA65, and MLDSA87 signature
    algorithms, and the PKIX and PKCS#8 key formats of RFC 9881. See
    golang/go#78888, accepted with the
    milestone Go1.27, and golang/go#77626.
  3. This repository does not hold a copy of crypto/x509. All 8 call sites import
    the standard library package. Thus the certificate half of ML-DSA is impossible on an
    older toolchain, with or without a third-party ML-DSA library. A Go 1.24 build gives
    UnknownSignatureAlgorithm for an ML-DSA certificate, and the chain does not verify.

Two alternatives keep Go 1.24, and this pull request rejects both:

Alternative Why not
github.com/cloudflare/circl for the primitive Point 3 stays true: crypto/x509 still cannot parse or verify an ML-DSA chain. It also adds a third-party dependency and moves the code away from the upstream crypto/mldsa API, thus each later rebase costs more.
Build tags, crypto/mldsa above Go 1.27 and CIRCL below 2 implementations of the same verification path to keep the same, a double test matrix, and point 3 still holds for the older half.

Why now. The Chrome 150 profiles already send the ML-DSA codepoints 0x0904, 0x0905,
and 0x0906 in signature_algorithms (u_parrots.go). The library could not honor them.
A server that selected one ended the handshake with an alert. This change closes that
gap between what the profile claims and what the library can do.

InsecureSkipVerify is not a workaround

Without this pull request, a client cannot complete an ML-DSA handshake at any trust
setting. InsecureSkipVerify does not help. A test on the commit a335c30, with Go
1.24, InsecureSkipVerify: true, and the profile HelloChrome_150_PSK, gives this
result on both ports:

test.openquantumsafe.org:6184 FAILED: tls: server's certificate contains an unsupported type of public key: <nil>
test.openquantumsafe.org:6197 FAILED: tls: server's certificate contains an unsupported type of public key: <nil>

InsecureSkipVerify removes the chain verification only, which is the call
certs[0].Verify(opts). The switch on the public key type at handshake_client.go:1221
runs after it, and always. That switch accepts RSA, ECDSA, and Ed25519 only.

The value <nil> in the message shows a second part of the same problem. Go 1.24
crypto/x509 reads the ML-DSA certificate without an error, but gives no public key,
because it does not know the algorithm OID.

2 independent gates stop the handshake, and a change of the trust settings opens
neither:

Gate Location Result
The type of the public key of the certificate handshake_client.go:1221 The error above.
The accept list of signature schemes handshake_client_tls13.go:853 tls: certificate used with invalid signature algorithm, because supportedSignatureAlgorithms holds no ML-DSA scheme.

Thus this work is not only about trust in a chain. The library cannot speak the protocol
with an ML-DSA server until it holds these changes.

Prerequisite: #413, the Chrome 150 profiles

Merge #413 first. This work needs HelloChrome_150 and HelloChrome_150_PSK, which
#413 adds. The base of this pull request is master, because a pull request cannot use
a branch of a fork as its base. Thus the diff below also holds the 2 commits of #413.
After #413 merges, only the 10 commits of this work stay.

The dependency is not a question of order alone. It holds for 3 reasons.

  1. The ML-DSA codepoints live in the Chrome 150 specification. The 3 raw values
    0x0904, 0x0905, and 0x0906 are in the profiles of Add Chrome 150 and Safari 18.5 profiles, with JA4 tests #413. This pull request gives
    them the names MLDSA44, MLDSA65, and MLDSA87.
  2. Every ML-DSA test uses HelloChrome_150_PSK. The client does not advertise
    ML-DSA in a default ClientHello, as the section "What this does not change" explains.
    Only a profile that holds the codepoints in its own signature_algorithms extension
    offers them.
  3. Without such a profile the feature has no user. The accept list alone changes
    nothing that a client can reach, because no hello in the library would offer ML-DSA.

Note that #404 adds Chrome 150 profiles as well, from a different author. If #404 merges
in place of #413, this work needs a rebase. The changes here stay correct, because they
need only a profile that holds the 3 ML-DSA codepoints.

What this adds

  • MLDSA44, MLDSA65, and MLDSA87 of type SignatureScheme, with the same names and
    values as Go 1.27 crypto/tls.
  • An ML-DSA case in verifyHandshakeSignature, and the map from the 3 schemes to no
    pre-hash in typeAndHashFromSignatureScheme.
  • An ML-DSA case in processCertsFromServer, which rejects ML-DSA below TLS 1.3.
  • clientSupportedSignatureAlgorithms, which the client uses to accept the
    CertificateVerify message of a server.
  • Names in place of the raw codepoints in the Chrome 150 profiles. The bytes on the wire
    do not change, which the JA4 tests show.

What this does not change

  • The bytes of a default ClientHello. The 2 call sites that build the hello keep
    supportedSignatureAlgorithms. Thus the fingerprint of HelloGolang stays the same.
    Only a profile that holds the ML-DSA codepoints in its own extension offers them.
    Go 1.27 crypto/tls advertises ML-DSA in its default hello. This library does not,
    because a change of the default fingerprint costs more here than the match gives.
  • The server role. The 3 server call sites of supportedSignatureAlgorithms keep
    that function. A uTLS server does not advertise the ML-DSA codepoints, and rejects an
    ML-DSA client certificate. defaults.go holds no ML-DSA. 3 tests hold this line.
  • ML-DSA client certificates. They stay unsupported.

The 64 reference tests

The Go version bump alone breaks 64 tests that replay a recorded transcript from
testdata. Go 1.27 gives the io.Reader of the caller to rand.CustomReader in
ecdh.GenerateKey, which returns the system source. Thus each run makes a different
ephemeral key, and no record can match.

The repair is 1 line: a go:debug cryptocustomrand=1 directive in
u_cryptocustomrand_test.go. That option makes rand.CustomReader give the reader of
the caller through.

Condition Failures
Go 1.27 64
Go 1.27 with cryptocustomrand=1 0

No transcript needs a new record, and the tests need no OpenSSL.

Test evidence

  • Unit tests for the codepoints, the scheme map, and a sign and verify cycle.

  • 3 server regression tests, which fail if ML-DSA enters the shared list.

  • An end-to-end test in 1 process: a standard library TLS 1.3 server with a
    generated ML-DSA certificate, and a uTLS client with HelloChrome_150_PSK.

  • Network tests against the Open Quantum Safe test server, which is NGINX with OpenSSL
    3.5 or later. They skip with go test -short. All 3 parameter sets pass:

    --- PASS: TestChrome150PSKConnectsToMLDSAServer/ML-DSA-44
    --- PASS: TestChrome150PSKConnectsToMLDSAServer/ML-DSA-65
    --- PASS: TestChrome150PSKConnectsToMLDSAServer/ML-DSA-87
    

    Each connection selects X25519MLKEM768 for the key exchange. Thus 1 connection covers
    post-quantum key exchange and post-quantum authentication.

  • The full test suite passes: ok github.com/refraction-networking/utls.

nihiloid added 12 commits August 29, 2026 13:35
Add HelloChrome_150 and HelloChrome_150_PSK. Chrome 150 sends the ML-DSA
signature algorithms 0x0904, 0x0905 and 0x0906 before the classical ones.
HelloChrome_Auto now selects HelloChrome_150.

Add HelloSafari_18_5. Safari 18.5 sends no ML-KEM group, it offers TLS 1.0 and
TLS 1.1 in supported_versions, and it ends with a padding extension.

Add HelloSafari_26_0 to the HelloSafari_26_3 case. Safari 26.0 sends the same
ClientHello as Safari 26.3.

Add u_parrots_ja4_test.go. It computes the JA4 of each profile and compares it
against a captured value. A second test checks that the extension shuffle keeps
the JA4 the same on every connection.
Add u_psk_resumption_test.go. It runs two connections of a PSK profile against a
TLS 1.3 server of this package. The first connection gets a session ticket. The
second connection sends the pre_shared_key extension and resumes the session,
which works only if utls computes the binders over the ClientHello that goes on
the wire.

The test runs HelloChrome_150_PSK and HelloChrome_114_Padding_PSK_Shuf. The
second profile holds a padding extension, whose length comes from the length of
the other extensions, and it shuffles its extensions, which must keep
pre_shared_key last.

A third connection uses HelloChrome_150, which holds no pre_shared_key
extension. It must not resume, although the session cache holds a ticket.
The ML-DSA code needs the standard library package crypto/mldsa. That
package starts with Go 1.27.

Go 1.27 ignores the io.Reader that the code gives to
ecdh.GenerateKey. Thus 64 tests that replay a recorded transcript from
testdata fail. A later change must call cryptotest.SetGlobalRandom and
record the transcripts again with OpenSSL 1.1.1. The JA4 tests and the
PSK resumption tests pass.
Add MLDSA44, MLDSA65, and MLDSA87 with the codepoints 0x0904, 0x0905,
and 0x0906. Map the 3 schemes to a new internal signature type, and to
no pre-hash.

Regenerate common_string.go. The file was not current. Thus the new
output also adds the names of the fake curves and the Kyber curves
from u_common.go. The names of the other values do not change.
Add an ML-DSA case to verifyHandshakeSignature. The case calls
mldsa.Verify with an empty context.
Add clientSupportedSignatureAlgorithms. It adds the 3 ML-DSA schemes
for TLS 1.3 only, and it makes a new slice. The shared list that the
server call sites read does not change.
Use clientSupportedSignatureAlgorithms at the 3 client call sites. The
client offers the ML-DSA schemes for TLS 1.3, and accepts a server
CertificateVerify message that uses them. The server call sites do not
change.
Replace the raw values 0x0904, 0x0905, and 0x0906 with MLDSA44,
MLDSA65, and MLDSA87. The bytes of the ClientHello do not change.
Add an ML-DSA case to the public key check in processCertsFromServer.
The case rejects ML-DSA below TLS 1.3. Without this case the client
stops before it verifies the signature.

Add a test that runs a full handshake. The server is the standard
library with an ML-DSA certificate. The client is uTLS with the
Chrome 150 PSK profile.
Add 2 tests that make real network connections with the
HelloChrome_150_PSK profile. Both tests skip with "go test -short".

The first test gets HTTP 204 from www.google.com twice. The second
connection resumes the session with the pre_shared_key extension.

The second test connects to the 3 ML-DSA ports of the Open Quantum
Safe test server. Each connection proves that the client offers the
ML-DSA codepoints, that the client verifies the ML-DSA
CertificateVerify signature of the server, and that crypto/x509
verifies a chain in which the intermediate certificate signs the leaf
with ML-DSA.

The root certificate of the test server is in the file. It expires on
30 November 2026.
Set cryptocustomrand=1 with a go:debug line. The reference tests
replay a recorded transcript and compare the bytes of the client
against the record. Thus the client must make the same ephemeral key
on each run.

Go 1.27 gives the io.Reader of the caller to rand.CustomReader in
ecdh.GenerateKey, which returns the system source. The setting makes
that function give the reader of the caller through. This repairs 64
tests. No transcript needs a new record.
Give supportedSignatureAlgorithms back to the 2 call sites that build
the hello. Only clientSupportedSignatureAlgorithms stays at the site
that accepts the CertificateVerify message of the server.

Thus the bytes of a default ClientHello do not change, and the
fingerprint of HelloGolang stays the same. A profile that holds the
ML-DSA codepoints in its own signature_algorithms extension still
offers them, such as HelloChrome_150. This repairs 32 reference tests.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant